--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7a02ca34925324cd265ddf244ce8e695eb31f731
Parents : 35937ec
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-24T14:33:48-05:00
refactor(frontend): streamline file download methods and update user interface elements across components
Changes
8 files changed, 290 insertions(+), 78 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/LxmfUserIcon.vue b/meshchatx/src/frontend/components/LxmfUserIcon.vue
index 80a0e8ad..faa04e0b 100644
--- a/meshchatx/src/frontend/components/LxmfUserIcon.vue
+++ b/meshchatx/src/frontend/components/LxmfUserIcon.vue
@@ -4,7 +4,7 @@
<div
v-if="customImage"
class="rounded-full overflow-hidden shrink-0 flex items-center justify-center"
- :class="iconClass || 'size-6'"
+ :class="resolvedShellClass"
:style="iconStyle"
>
<img :src="customImage" class="w-full h-full object-cover" />
@@ -13,14 +13,14 @@
v-else-if="iconName"
class="p-[10%] rounded-full shrink-0 flex items-center justify-center"
:style="[iconStyle, { 'background-color': finalBackgroundColor }]"
- :class="iconClass || 'size-6'"
+ :class="resolvedShellClass"
>
<MaterialDesignIcon :icon-name="iconName" class="size-full" :style="{ color: finalForegroundColor }" />
</div>
<div
v-else
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
- :class="iconClass || 'size-6'"
+ :class="resolvedShellClass"
:style="iconStyle"
>
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
@@ -61,6 +61,19 @@ export default {
},
},
computed: {
+ resolvedShellClass() {
+ const extra = (this.iconClass || "").trim();
+ if (
+ /\bsize-[\w.]+\b/.test(extra) ||
+ /\bw-[\w.]+\b/.test(extra) ||
+ /\bh-[\w.]+\b/.test(extra) ||
+ /\bmin-w-/.test(extra) ||
+ /\bmin-h-/.test(extra)
+ ) {
+ return extra;
+ }
+ return ["size-6", extra].filter(Boolean).join(" ").trim();
+ },
finalForegroundColor() {
return this.iconForegroundColour && this.iconForegroundColour !== ""
? this.iconForegroundColour
diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index c9f5f9d1..30856ba7 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -233,6 +233,7 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Utils from "../../js/Utils";
+import DownloadUtils from "../../js/DownloadUtils";
import MicronParser from "../../js/MicronParser.js";
import GlobalState from "../../js/GlobalState.js";
import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
@@ -504,17 +505,9 @@ export default {
}
}
},
- downloadTextAsFile(content, filename) {
+ async downloadTextAsFile(content, filename) {
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = filename;
- a.rel = "noopener";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ await DownloadUtils.downloadFile(filename, blob);
},
muExportBasename(archive) {
let base = (archive.page_path || "page").split("/").pop() || "page";
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index a1dab6a2..66b63c64 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -894,7 +894,7 @@
<span
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2 shrink-0"
>
- {{ formatTimeAgo(announce.updated_at) }} ago
+ {{ formatTimeAgo(announce.updated_at) }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 1c9989e9..cd6be554 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -995,7 +995,7 @@ export default {
const response = await window.api.post("/api/v1/reticulum/interfaces/export");
// download file to browser
- DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
+ await DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
} catch (e) {
DialogUtils.alert(this.$t("interfaces.failed_export_all"));
console.error(e);
@@ -1009,7 +1009,7 @@ export default {
});
// download file to browser
- DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
+ await DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch (e) {
DialogUtils.alert(this.$t("interfaces.failed_export_single"));
console.error(e);
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index a1fecb74..6713e1be 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -62,7 +62,9 @@
>
<div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
- <h3 class="text-lg font-bold text-gray-900 dark:text-white">Share Contact</h3>
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">
+ {{ $t("messages.share_contact_modal_title") }}
+ </h3>
<button
type="button"
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
@@ -77,7 +79,7 @@
<input
v-model="contactsSearch"
type="text"
- placeholder="Search contacts..."
+ :placeholder="$t('messages.share_contact_search_placeholder')"
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
@@ -94,10 +96,9 @@
@click="shareContact(contact)"
>
<div
- class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
- >
- <MaterialDesignIcon icon-name="account" class="size-6" />
- </div>
+ class="h-10 w-10 shrink-0 rounded-full border-2 border-dashed border-gray-300 dark:border-zinc-600 bg-gray-50 dark:bg-zinc-800/80"
+ aria-hidden="true"
+ />
<div class="min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate">
{{ contact.name }}
@@ -1487,6 +1488,7 @@
<script>
import Utils from "../../js/Utils";
+import DownloadUtils from "../../js/DownloadUtils";
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
import {
@@ -3766,33 +3768,7 @@ export default {
return this.imageGroupSortedChron(items).map((it) => this.lxmfImageUrl(it.lxmf_message.hash));
},
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
- // create blob from base64 encoded file bytes
- const byteCharacters = atob(fileBytesBase64);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
- const byteArray = new Uint8Array(byteNumbers);
- const blob = new Blob([byteArray]);
-
- // create object url for blob
- const objectUrl = URL.createObjectURL(blob);
-
- // create link element to download blob
- const link = document.createElement("a");
- link.href = objectUrl;
- link.download = fileName;
- link.style.display = "none";
- document.body.append(link);
-
- // click link to download file in browser
- link.click();
-
- // link element is no longer needed
- link.remove();
-
- // revoke object url to clear memory
- setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
+ DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
},
async processAudioForSelectedPeerChatItems() {
for (const chatItem of this.selectedPeerChatItems) {
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 6887e2de..f43f863e 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -450,6 +450,7 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import Utils from "../../js/Utils";
+import DownloadUtils from "../../js/DownloadUtils";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
@@ -1762,33 +1763,7 @@ export default {
ToastUtils.warning(this.$t("nomadnet.unsupported_url") + url);
},
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
- // create blob from base64 encoded file bytes
- const byteCharacters = atob(fileBytesBase64);
- const byteNumbers = new Array(byteCharacters.length);
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
- const byteArray = new Uint8Array(byteNumbers);
- const blob = new Blob([byteArray]);
-
- // create object url for blob
- const objectUrl = URL.createObjectURL(blob);
-
- // create link element to download blob
- const link = document.createElement("a");
- link.href = objectUrl;
- link.download = fileName;
- link.style.display = "none";
- document.body.append(link);
-
- // click link to download file in browser
- link.click();
-
- // link element is no longer needed
- link.remove();
-
- // revoke object url to clear memory
- setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
+ DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
},
formatBytesPerSecond: function (bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index b1c559c5..507d9d0b 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -358,6 +358,10 @@
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
Rename Section
</ContextMenuItem>
+ <ContextMenuItem @click="exportSectionFavouritesFromContext">
+ <MaterialDesignIcon icon-name="file-export" class="size-4 text-gray-400" />
+ {{ $t("nomadnet.export_section_favourites") }}
+ </ContextMenuItem>
<ContextMenuItem
:item-class="
'text-red-600 dark:text-red-400' +
@@ -423,7 +427,9 @@
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
<span class="truncate">{{
- $t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
+ $t("nomadnet.announced_time_ago", {
+ time: formatTimeAgoForI18n(node.updated_at),
+ })
}}</span>
<span
class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
@@ -534,6 +540,7 @@ import DialogUtils from "../../js/DialogUtils";
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
+import DownloadUtils from "../../js/DownloadUtils";
export default {
name: "NomadNetworkSidebar",
@@ -730,11 +737,18 @@ export default {
};
this._smUpResize();
this._smUpMql.addEventListener("change", this._smUpResize);
+ this._onNomadnetFavouritesLayoutImported = () => {
+ this.loadFavouriteLayout();
+ };
+ GlobalEmitter.on("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
},
unmounted() {
if (this._smUpMql && this._smUpResize) {
this._smUpMql.removeEventListener("change", this._smUpResize);
}
+ if (this._onNomadnetFavouritesLayoutImported) {
+ GlobalEmitter.off("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
+ }
},
methods: {
startEditingSection(section) {
@@ -1067,6 +1081,41 @@ export default {
};
this.favouriteContextMenu.show = false;
},
+ async exportSectionFavouritesFromContext() {
+ const sid = this.sectionContextMenu.sectionId;
+ if (!sid) {
+ this.closeContextMenus();
+ return;
+ }
+ const section = this.sections.find((s) => s.id === sid);
+ this.closeContextMenus();
+ if (!section) {
+ return;
+ }
+ const hashes = this.favouritesBySection[sid] || [];
+ const payload = {
+ format: "meshchatx/nomadnet_favourites_section/v1",
+ exported_at: new Date().toISOString(),
+ section: {
+ id: section.id,
+ name: section.name,
+ collapsed: section.collapsed === true,
+ },
+ destination_hashes: hashes.filter((h) => typeof h === "string"),
+ };
+ const slug = (section.name || "section")
+ .replace(/[^a-z0-9]+/gi, "_")
+ .replace(/^_|_$/g, "")
+ .slice(0, 48);
+ const namePart = slug || "section";
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
+ try {
+ await DownloadUtils.downloadFile(`nomadnet_favourites_section_${namePart}.json`, blob);
+ ToastUtils.success(this.$t("nomadnet.section_favourites_exported"));
+ } catch {
+ ToastUtils.error(this.$t("nomadnet.section_favourites_export_failed"));
+ }
+ },
closeContextMenus() {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
@@ -1227,6 +1276,9 @@ export default {
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
+ formatTimeAgoForI18n: function (datetimeString) {
+ return Utils.formatTimeAgoForI18n(datetimeString);
+ },
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 1789a6c7..56e14589 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -622,6 +622,43 @@
@change="importFolders"
/>
</div>
+
+ <div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-teal-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-teal-500 transition group"
+ @click="exportNomadnetFavouritesLayout"
+ >
+ <MaterialDesignIcon
+ icon-name="file-export"
+ class="size-6 text-teal-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">
+ {{ $t("maintenance.export_nomadnet_favourites") }}
+ </div>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-cyan-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-cyan-500 transition group"
+ @click="triggerNomadnetFavouritesImport"
+ >
+ <MaterialDesignIcon
+ icon-name="import"
+ class="size-6 text-cyan-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">
+ {{ $t("maintenance.import_nomadnet_favourites") }}
+ </div>
+ </button>
+ <input
+ ref="nomadnetFavouritesImportFile"
+ type="file"
+ accept=".json"
+ class="hidden"
+ @change="importNomadnetFavouritesLayoutFile"
+ />
+ </div>
</div>
</section>
@@ -2077,7 +2114,7 @@
<div class="text-xs text-gray-600 dark:text-gray-400">
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">{{
$t("app.last_synced", {
- time: formatSecondsAgo(
+ time: formatSecondsAgoForI18n(
config.lxmf_preferred_propagation_node_last_synced_at
),
})
@@ -2271,6 +2308,8 @@ import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
+import DownloadUtils from "../../js/DownloadUtils";
+import GlobalEmitter from "../../js/GlobalEmitter";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Toggle from "../forms/Toggle.vue";
import ShortcutRecorder from "./ShortcutRecorder.vue";
@@ -2460,6 +2499,8 @@ export default {
"maintenance.export_messages_desc",
"maintenance.import_messages",
"maintenance.import_messages_desc",
+ "maintenance.export_nomadnet_favourites",
+ "maintenance.import_nomadnet_favourites",
"Automatic Backup Limit",
"Export Folders",
"Import Folders",
@@ -3703,9 +3744,171 @@ export default {
// Reset input
event.target.value = "";
},
+ normalizeNomadnetFavouritesLayoutShape(layout) {
+ if (!layout || typeof layout !== "object" || !Array.isArray(layout.sections)) {
+ return null;
+ }
+ const favouritesBySection =
+ layout.favouritesBySection && typeof layout.favouritesBySection === "object"
+ ? layout.favouritesBySection
+ : {};
+ const sectionOrder = Array.isArray(layout.sectionOrder)
+ ? layout.sectionOrder
+ : layout.sections.map((s) => s && s.id).filter(Boolean);
+ const sections = layout.sections
+ .filter((s) => s && typeof s.id === "string")
+ .map((s) => ({
+ id: s.id,
+ name: typeof s.name === "string" ? s.name : "",
+ collapsed: s.collapsed === true,
+ }));
+ const sanitizedMap = {};
+ for (const k of Object.keys(favouritesBySection)) {
+ const arr = favouritesBySection[k];
+ if (Array.isArray(arr)) {
+ sanitizedMap[k] = arr.filter((h) => typeof h === "string");
+ }
+ }
+ return { sections, sectionOrder, favouritesBySection: sanitizedMap };
+ },
+ parseNomadnetFavouritesImportData(data) {
+ if (!data || typeof data !== "object") {
+ return null;
+ }
+ if (data.format === "meshchatx/nomadnet_favourites/v1" && data.layout && typeof data.layout === "object") {
+ const layout = this.normalizeNomadnetFavouritesLayoutShape(data.layout);
+ return layout ? { kind: "full", layout } : null;
+ }
+ if (data.format === "meshchatx/nomadnet_favourites_section/v1") {
+ const sec = data.section;
+ if (!sec || typeof sec.id !== "string") {
+ return null;
+ }
+ return { kind: "section", payload: data };
+ }
+ const layout = this.normalizeNomadnetFavouritesLayoutShape(data);
+ return layout ? { kind: "full", layout } : null;
+ },
+ mergeNomadnetFavouritesSectionImport(payload) {
+ const sec = payload.section;
+ const hashes = Array.isArray(payload.destination_hashes)
+ ? payload.destination_hashes.filter((h) => typeof h === "string")
+ : [];
+ let raw = null;
+ try {
+ raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
+ } catch {
+ raw = null;
+ }
+ let base = { sections: [], sectionOrder: [], favouritesBySection: {} };
+ if (raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
+ if (normalized) {
+ base = normalized;
+ }
+ } catch {
+ // keep default base
+ }
+ }
+ const sections = [...base.sections];
+ const sectionOrder = [...base.sectionOrder];
+ const favouritesBySection = { ...base.favouritesBySection };
+ const idx = sections.findIndex((s) => s.id === sec.id);
+ const sectionObj = {
+ id: sec.id,
+ name:
+ typeof sec.name === "string" && sec.name.trim() !== "" ? sec.name : this.$t("nomadnet.favourites"),
+ collapsed: sec.collapsed === true,
+ };
+ if (idx === -1) {
+ sections.push(sectionObj);
+ if (!sectionOrder.includes(sec.id)) {
+ sectionOrder.push(sec.id);
+ }
+ } else {
+ sections[idx] = { ...sections[idx], ...sectionObj };
+ }
+ favouritesBySection[sec.id] = hashes;
+ const merged = this.normalizeNomadnetFavouritesLayoutShape({
+ sections,
+ sectionOrder,
+ favouritesBySection,
+ });
+ if (!merged) {
+ throw new Error("invalid layout");
+ }
+ localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(merged));
+ },
+ async exportNomadnetFavouritesLayout() {
+ let layout = { sections: [], sectionOrder: [], favouritesBySection: {} };
+ try {
+ const raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
+ if (normalized) {
+ layout = normalized;
+ }
+ }
+ } catch {
+ // keep empty layout
+ }
+ const body = {
+ format: "meshchatx/nomadnet_favourites/v1",
+ exported_at: new Date().toISOString(),
+ layout,
+ };
+ const blob = new Blob([JSON.stringify(body, null, 2)], { type: "application/json" });
+ try {
+ await DownloadUtils.downloadFile(
+ `meshchat_nomadnet_favourites_${new Date().toISOString().slice(0, 10)}.json`,
+ blob
+ );
+ ToastUtils.success(this.$t("maintenance.nomadnet_favourites_exported"));
+ } catch {
+ ToastUtils.error(this.$t("maintenance.nomadnet_favourites_export_failed"));
+ }
+ },
+ triggerNomadnetFavouritesImport() {
+ this.$refs.nomadnetFavouritesImportFile.click();
+ },
+ importNomadnetFavouritesLayoutFile(event) {
+ const file = event.target.files[0];
+ if (!file) {
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = JSON.parse(e.target.result);
+ const parsed = this.parseNomadnetFavouritesImportData(data);
+ if (!parsed) {
+ throw new Error("invalid file");
+ }
+ if (parsed.kind === "full") {
+ localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(parsed.layout));
+ } else if (parsed.kind === "section") {
+ this.mergeNomadnetFavouritesSectionImport(parsed.payload);
+ } else {
+ throw new Error("invalid file");
+ }
+ GlobalEmitter.emit("nomadnet-favourites-layout-imported");
+ ToastUtils.success(this.$t("maintenance.nomadnet_favourites_imported"));
+ } catch {
+ ToastUtils.error(this.$t("maintenance.nomadnet_favourites_import_failed"));
+ }
+ };
+ reader.readAsText(file);
+ event.target.value = "";
+ },
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
+ formatSecondsAgoForI18n: function (seconds) {
+ return Utils.formatSecondsAgoForI18n(seconds);
+ },
},
};
</script>
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────